Skip to content

Add write-side inline cache for property assignment (obj.prop = value)#2546

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:ws1-write-inline-cache
Jun 25, 2026
Merged

Add write-side inline cache for property assignment (obj.prop = value)#2546
lahma merged 1 commit into
sebastienros:mainfrom
lahma:ws1-write-inline-cache

Conversation

@lahma

@lahma lahma commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Reads of obj.prop already use a version-gated own-property inline cache in JintMemberExpression.GetValue that skips the dictionary lookup on repeat reads. Writes had no equivalent: every obj.prop = value rented a Reference, called PutValue, re-hashed the property key, and did a dictionary lookup before the in-place store.

This PR adds the write-side counterpart, TryAssignFast, hooked into SimpleAssignmentExpression.SetValue. It reuses the same cache slots (_cachedReadObject / _cachedReadVersion / _cachedReadDescriptor), so a summary.res = summary.res + 1 site shares one cache between its read and its write. When the receiver is a PlainObject whose shape is unchanged and the own property is a live writable, non-accessor, non-custom data descriptor, the new value is written straight into the descriptor — no Reference rent, no key hash, no dictionary lookup — exactly the in-place store ObjectInstance.Set already performs (which by design does not bump _propertiesVersion).

How it stays correct

  • The writability/data/custom flags are re-read live on every store via (_flags & (NonData | CustomJsValue | Writable)) == Writable, never cached as a decision. This is required because Object.defineProperty(obj, p, { writable: false }) flips the flag in place on the same descriptor object without bumping the version — only the live flag read catches it. Data↔accessor and get/set changes allocate a new descriptor via SetOwnProperty, which does bump the version → cache miss.
  • The fast path declines only at the eligibility gate, before evaluating anything (gate: determined string-named property, non-optional, non-short-circuiting, no custom resolver, not super, and Suspendable is null). Once base and RHS are evaluated — each exactly once, in spec order — it always completes the assignment: either the in-place store, or a fallback through PutValue rented from the already-resolved base + key. So a side-effecting base or RHS is never evaluated twice, and absent / accessor / read-only / custom-value / non-PlainObject cases keep prototype-setter dispatch, CreateDataProperty, and the strict read-only TypeError.
  • Scoped to SimpleAssignmentExpression only; compound (+=) / update (++) / discard paths are untouched.

Benchmarks

BenchmarkDotNet DefaultJob, net10.0, AMD Ryzen 9 5950X, .NET 10.0.9. Baseline vs. this branch measured back-to-back in the same session (only the two interpreter files reverted for the baseline). Literal3/Literal8 build via object literals — they don't touch the assignment path and serve as drift controls (here flat: +2.1% / −1.4%, opposite signs = noise, not drift).

Method Baseline This PR Δ time Allocated
ObjectAccess.UpdateObjectProperty (read+write loop) 103.28 ms 89.34 ms −13.5 % 60.43 MB (unchanged)
ObjectAccess.WriteObjectProperty (pure write, new) 82.78 ms 68.56 ms −17.2 % 30.22 MB (unchanged)
PropertyAlloc.Constructor1 (this.value = v) 89.75 ms 79.40 ms −11.5 % 83.63 MB (unchanged)
PropertyAlloc.Constructor3 (this.a=a;this.b=b;this.c=c) 133.73 ms 128.82 ms −3.7 % 134.88 MB (unchanged)
PropertyAlloc.Literal3 (control) 50.10 ms 51.15 ms +2.1 % 95.21 MB (unchanged)
PropertyAlloc.Literal8 (control) 64.54 ms 63.61 ms −1.4 % 159.92 MB (unchanged)
  • UpdateObjectProperty / WriteObjectProperty: steady-state writes lose the per-store Reference rent + ToObject + key hash + dictionary lookup. WriteObjectProperty (added here) is a pure write loop — its LHS node is never read — proving the write-miss population path warms the cache without any read.
  • Constructor1/Constructor3 (new-property writes, where the fast path falls back): no regression, and in fact faster, because TryAssignFast's base resolution is leaner than the previous EvaluateReferencePutValue path even with the one extra GetOwnProperty miss-probe.
  • Allocations are unchanged everywhere (the in-place store and the fallback are both allocation-free; the reported MB is the script's own JsNumber boxing).

Testing

  • Jint.Tests and Jint.Tests.PublicInterface: all green.
  • Jint.Tests.Test262: baseline-equal (the only failures are 4 pre-existing annexB/.../RegExp-*-escape-BMP cases that hit the 30 s timeout — identical with and without this change).
  • Targeted checks confirm: in-place updates, new-property creation, accessor setters still run, sloppy read-only no-op, strict read-only TypeError, Object.defineProperty flipping writable true→false in place is honored, inherited prototype setters, frozen objects, and array element / length writes (non-PlainObject fallback).

🤖 Generated with Claude Code

Reads of `obj.prop` already use a version-gated own-property inline cache in
JintMemberExpression.GetValue that skips the dictionary lookup on repeat reads.
Writes had no equivalent: every `obj.prop = value` rented a Reference, called
PutValue, re-hashed the property key, and did a dictionary lookup before the
in-place store.

Add TryAssignFast as the write-side counterpart, reusing the same cache slots.
When the receiver is a PlainObject whose shape is unchanged and the own property
is a live writable, non-accessor, non-custom data descriptor, the value is
written straight into the descriptor with no Reference rent, key hash, or lookup
— exactly the in-place store ObjectInstance.Set already performs (which by design
does not bump _propertiesVersion). The writability/data/custom flags are re-read
live on every store, because Object.defineProperty mutates them in place without
a version bump.

The fast path declines only at the eligibility gate (before evaluating anything);
once base and RHS are evaluated (each once, in spec order) it always completes the
assignment, falling back to PutValue rented from the already-resolved base+key for
absent / accessor / read-only / custom-value / non-PlainObject cases — so a
side-effecting base or RHS is never evaluated twice and prototype-setter,
CreateDataProperty, and strict read-only semantics are preserved.

Benchmarks (net10.0, AMD 5950X): ObjectAccess.UpdateObjectProperty -9.5..13%,
new WriteObjectProperty (pure write, write-miss population) -14%,
PropertyAlloc.Constructor1 -11%; allocations unchanged; no read-path or Test262
regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lahma
lahma enabled auto-merge (squash) June 25, 2026 19:42
@lahma
lahma merged commit 501f47d into sebastienros:main Jun 25, 2026
4 checks passed
@lahma
lahma deleted the ws1-write-inline-cache branch June 28, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant